| 1 | import type { Metadata } from "next"; |
| 2 | import Link from "next/link"; |
| 3 | import { notFound } from "next/navigation"; |
| 4 | import { Card, PageHeader, SectionHeader } from "@/components/ui"; |
| 5 | import { PartyTag } from "@/components/party-tag"; |
| 6 | import { MemberAvatar } from "@/components/member-avatar"; |
| 7 | import { StatusPill, classifyStatus } from "@/components/status-pill"; |
| 8 | import { getStateDetail } from "@/lib/feed"; |
| 9 | import { formatDate } from "@/lib/format"; |
| 10 | |
| 11 | export const revalidate = 300; |
| 12 | |
| 13 | export function generateMetadata({ |
| 14 | params, |
| 15 | }: { |
| 16 | params: { code: string }; |
| 17 | }): Metadata { |
| 18 | const detail = getStateDetail(params.code); |
| 19 | if (!detail) return { title: "State not found" }; |
| 20 | return { title: `${detail.name} (${detail.code})` }; |
| 21 | } |
| 22 | |
| 23 | function confidenceColor(c: "ok" | "stale" | "missing"): string { |
| 24 | return c === "ok" ? "var(--dk-yea)" : c === "stale" ? "var(--dk-hold)" : "var(--dk-ash)"; |
| 25 | } |
| 26 | |
| 27 | function confidenceLabel(c: "ok" | "stale" | "missing"): string { |
| 28 | return c === "ok" ? "Current" : c === "stale" ? "Stale" : "Coverage rolling out"; |
| 29 | } |
| 30 | |
| 31 | function formatMemberDistrict(chamber: string, district: number | null): string { |
| 32 | if (chamber === "senate") return ""; |
| 33 | if (district === null || district <= 0) return " · At-Large"; |
| 34 | return ` · Dist. ${district}`; |
| 35 | } |
| 36 | |
| 37 | export default function StateDetailPage({ |
| 38 | params, |
| 39 | }: { |
| 40 | params: { code: string }; |
| 41 | }) { |
| 42 | const detail = getStateDetail(params.code); |
| 43 | if (!detail) notFound(); |
| 44 | |
| 45 | return ( |
| 46 | <div |
| 47 | className="mx-auto w-full max-w-[860px] px-6 sm:px-8" |
| 48 | style={{ |
| 49 | paddingTop: "clamp(32px, 6vw, 56px)", |
| 50 | paddingBottom: "clamp(48px, 8vw, 80px)", |
| 51 | }} |
| 52 | > |
| 53 | <Link |
| 54 | href="/states" |
| 55 | className="focus-ring tap no-underline hover:underline" |
| 56 | style={{ |
| 57 | display: "inline-flex", |
| 58 | alignItems: "center", |
| 59 | gap: "8px", |
| 60 | fontFamily: "var(--font-mono), ui-monospace, monospace", |
| 61 | fontSize: "13px", |
| 62 | letterSpacing: "0.08em", |
| 63 | color: "var(--dk-signal)", |
| 64 | marginBottom: "32px", |
| 65 | }} |
| 66 | > |
| 67 | ← States |
| 68 | </Link> |
| 69 | |
| 70 | <PageHeader |
| 71 | eyebrow={`State · ${detail.code}`} |
| 72 | title={detail.name} |
| 73 | lead={`Congressional delegation and sponsored bills for ${detail.name}. State-level legislative coverage is shown honestly below.`} |
| 74 | /> |
| 75 | |
| 76 | {} |
| 77 | <div className="mt-8"> |
| 78 | <Card className="p-5"> |
| 79 | <div className="flex flex-wrap items-center gap-3"> |
| 80 | <span |
| 81 | className="inline-flex items-center gap-1.5" |
| 82 | style={{ color: confidenceColor(detail.confidence) }} |
| 83 | > |
| 84 | <span |
| 85 | aria-hidden="true" |
| 86 | className="inline-block h-2 w-2 rounded-full" |
| 87 | style={{ backgroundColor: confidenceColor(detail.confidence) }} |
| 88 | /> |
| 89 | <span className="label"> |
| 90 | {confidenceLabel(detail.confidence)} |
| 91 | </span> |
| 92 | </span> |
| 93 | <span className="label text-ash-2"> |
| 94 | {detail.lastScrape ? `Last seen ${formatDate(detail.lastScrape)}` : "No state-leg scrape on record"} |
| 95 | </span> |
| 96 | </div> |
| 97 | {detail.missingTypes.length > 0 ? ( |
| 98 | <p className="mt-3 font-mono text-[13px] text-ash"> |
| 99 | State-legislative data not in the record: {detail.missingTypes.join(", ")}. Federal delegation and bill sponsorships below are from congress.gov. |
| 100 | </p> |
| 101 | ) : null} |
| 102 | </Card> |
| 103 | </div> |
| 104 | |
| 105 | <div className="mt-10 grid gap-8 lg:grid-cols-2"> |
| 106 | {} |
| 107 | <div> |
| 108 | <SectionHeader |
| 109 | eyebrow={`Current delegation · ${detail.members.length}`} |
| 110 | title="Members of Congress" |
| 111 | /> |
| 112 | {detail.members.length === 0 ? ( |
| 113 | <Card className="p-5"> |
| 114 | <p className="font-mono text-[13px] text-ash-2"> |
| 115 | No current members of Congress linked to {detail.name} in the record. |
| 116 | </p> |
| 117 | </Card> |
| 118 | ) : ( |
| 119 | <Card className="divide-y divide-[color:var(--dk-line)]"> |
| 120 | {detail.members.map((m) => ( |
| 121 | <Link |
| 122 | key={m.id} |
| 123 | href={m.href} |
| 124 | className="focus-ring group flex items-center gap-3 p-4 transition-colors hover:bg-graphite" |
| 125 | > |
| 126 | <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="sm" /> |
| 127 | <span className="min-w-0 flex-1"> |
| 128 | <span className="block font-mono text-[13px] text-paper group-hover:underline"> |
| 129 | {m.name} |
| 130 | </span> |
| 131 | <span className="label mt-1 block text-ash-2"> |
| 132 | {m.chamber === "senate" ? "Senate" : "House"} |
| 133 | {formatMemberDistrict(m.chamber, m.district)} |
| 134 | </span> |
| 135 | </span> |
| 136 | <PartyTag party={m.partyCode} /> |
| 137 | </Link> |
| 138 | ))} |
| 139 | </Card> |
| 140 | )} |
| 141 | </div> |
| 142 | |
| 143 | {} |
| 144 | <div> |
| 145 | <SectionHeader |
| 146 | eyebrow={`Bills · ${detail.bills.length}`} |
| 147 | title="Sponsored by delegation" |
| 148 | /> |
| 149 | {detail.bills.length === 0 ? ( |
| 150 | <Card className="p-5"> |
| 151 | <p className="font-mono text-[13px] text-ash-2"> |
| 152 | No bills sponsored by the {detail.name} delegation are in the record. |
| 153 | </p> |
| 154 | </Card> |
| 155 | ) : ( |
| 156 | <Card className="divide-y divide-[color:var(--dk-line)]"> |
| 157 | {detail.bills.slice(0, 20).map((b) => ( |
| 158 | <div key={b.id} className="p-4"> |
| 159 | <Link |
| 160 | href={b.href} |
| 161 | className="focus-ring font-mono text-[13px] text-paper underline underline-offset-4 hover:text-paper" |
| 162 | > |
| 163 | {b.number} |
| 164 | </Link> |
| 165 | <p className="mt-1 font-mono text-[12px] text-ash">{b.title}</p> |
| 166 | <div className="mt-2"> |
| 167 | <StatusPill variant={classifyStatus(b.status)}> |
| 168 | {b.policyArea || "Introduced"} |
| 169 | </StatusPill> |
| 170 | </div> |
| 171 | </div> |
| 172 | ))} |
| 173 | </Card> |
| 174 | )} |
| 175 | </div> |
| 176 | </div> |
| 177 | </div> |
| 178 | ); |
| 179 | } |